Skip to content

macOS release signing: Developer ID + notarization + stapling - #95

Merged
rohanpandula merged 2 commits into
mainfrom
feat/macos-signing-notarization
Aug 14, 2026
Merged

macOS release signing: Developer ID + notarization + stapling#95
rohanpandula merged 2 commits into
mainfrom
feat/macos-signing-notarization

Conversation

@rohanpandula

@rohanpandula rohanpandula commented Aug 14, 2026

Copy link
Copy Markdown
Owner

What this does

Turns the release train's macOS DMGs from ad-hoc-signed/unnotarized into Developer-ID-signed, notarized, stapled, Gatekeeper-clean builds — closing the oldest known-limitation on every release since beta.1.

  • package_app.sh: signing block parameterized by SCANSTUDIO_SIGNING_IDENTITY (default - = the exact ad-hoc behavior local builds and PR CI have always had). A real identity signs every Mach-O in the bundle individually with a secure timestamp (--deep never descends into Resources, where the entire BridgeRuntime CPython tree lives, and notarytool rejects any unsigned Mach-O), hardened runtime on executables, and the disable-library-validation entitlement the bridge's ctypes loader needs to dlopen the bundled same-team-signed libusb.
  • scripts/notarize_dmg.sh (new): signs the DMG, submits via notarytool with the App Store Connect API key, treats anything but status: Accepted as failure (notarytool can exit 0 on a rejected submission — the status line is the authority; the per-file rejection log is fetched before failing), staples, then runs spctl -a -t open — Gatekeeper's own verdict on the artifact a user will double-click.
  • release.yml DMG job: imports the certificate into a throwaway keychain (deleted if: always()), fails loudly if any signing secret is missing (a release may no longer silently ship ad-hoc), and notarizes before SHA256SUMS/latest.json are emitted — stapling mutates the DMG, so published checksums are computed from the stapled artifact.

Secrets consumed (names only)

MACOS_SIGNING_CERT_P12_BASE64, MACOS_SIGNING_CERT_PASSWORD, APPSTORE_CONNECT_API_KEY_P8, APPSTORE_CONNECT_API_KEY_ID, APPSTORE_CONNECT_API_ISSUER_ID — all present in repository secrets.

Verification

  • GitHub Actions pin policy passes over the edited workflow (39 pinned uses; new steps are plain run:).
  • Both zsh scripts pass zsh -n; the ad-hoc default path is byte-for-byte the previous behavior and stays exercised by the existing PR-CI package jobs.
  • The live proof is the next tagged release: the first DMG job with these secrets produces the first signed, notarized ScanStudio.

Round 2 (two independent adversarial reviews)

Both reviews confirmed the mechanics (empirically: --deep does not clobber the Resources entitlements, the file(1) sweep classifies the real CPython tree correctly, empty-array zsh expansion keeps the ad-hoc path byte-identical) and found the things that mattered:

  • The app itself is now notarized + stapled before the DMG is built — the in-app updater's publisher-trust gate reads the stapled ticket on the installed app (a DMG ticket doesn't travel), and the app ticket is what makes offline first-launch work. notarize_artifact.sh handles both artifacts.
  • ScanStudioUpdateTeamIdentifier is stamped into the packaged Info.plist with the real Team ID — the second independent blocker on the updater gate, named by docs/AUTO-UPDATE.md step 1 and missed in round 1.
  • notarytool diagnostics survive non-zero exits (auth failures, network errors, the 45m timeout) — previously errexit killed the script before the captured output printed, leaving an empty log exactly when it mattered.
  • .p8 contract made explicit: raw PEM, BEGIN PRIVATE KEY guard that fails in seconds rather than after a 40-minute build; stapler validate after every staple; key material also removed in the always() cleanup; the release job gains timeout-minutes.
  • signing-dry-run.yml (manual dispatch): the whole path — keychain, notarization-grade package, an assertion the entitlement landed on the bridge interpreter and only there, app notarize/staple, a live ctypes.CDLL of the bundled libusb under the hardened entitled interpreter (the check the notary service cannot make), DMG notarize/staple, inspectable artifact. The release path's first execution is no longer a published tag.
  • Entitlement rationale corrected (host libsane via find_library is foreign-team — the actual thing library validation blocks), sweep pre-filter, pre-staple digest labeling, AUTO-UPDATE.md updated to the shipped trust state.

Run order after merge: trigger Signing dry run once from the Actions tab and eyeball/install its artifact; then the next tag ships signed.

Every release so far shipped ad-hoc-signed, unnotarized DMGs -- the
longest-standing known limitation on the release train. With the Apple
Developer account in place, the release workflow now produces
Gatekeeper-clean builds:

- package_app.sh's signing block is identity-parameterized
  (SCANSTUDIO_SIGNING_IDENTITY, default "-"): local builds and PR CI keep
  the exact ad-hoc behavior they have always had, while a real identity
  switches to notarization-grade signing -- every Mach-O in the bundle
  signed individually with a secure timestamp (notarytool rejects any
  unsigned Mach-O, and --deep never descends into Resources where the
  whole BridgeRuntime CPython tree lives), hardened runtime on every
  executable, and executables carrying the one entitlement the bridge's
  ctypes loader needs to dlopen the bundled same-team-signed libusb under
  library validation.
- New scripts/notarize_dmg.sh signs the DMG, submits it through notarytool
  with the App Store Connect API key, treats anything but "status:
  Accepted" as failure (notarytool can exit 0 on a rejected submission --
  the status line is the authority, and the per-file rejection log is
  fetched before failing), staples, and finishes with Gatekeeper's own
  spctl verdict on the stapled artifact.
- The release DMG job imports the certificate into a throwaway keychain
  (deleted in an always() cleanup step), fails loudly when any signing
  secret is missing rather than silently shipping another ad-hoc build,
  and notarizes BEFORE SHA256SUMS/latest.json are emitted -- stapling
  mutates the DMG, so every published checksum is computed from the
  stapled artifact.

Secrets consumed (names only; values live in repository secrets):
MACOS_SIGNING_CERT_P12_BASE64, MACOS_SIGNING_CERT_PASSWORD,
APPSTORE_CONNECT_API_KEY_P8, APPSTORE_CONNECT_API_KEY_ID,
APPSTORE_CONNECT_API_ISSUER_ID.

The GitHub Actions pin policy passes over the edited workflow (the new
steps are plain run: steps); both zsh scripts pass zsh -n; the ad-hoc
default path is exercised by the existing PR-CI package jobs.
…harden the pipeline

Two independent adversarial reviews of the first commit; every required
finding addressed:

- The .app is now notarized and stapled BEFORE the DMG is built (then the
  DMG again, as before): the in-app updater's publisher-trust gate requires
  a stapled ticket on the INSTALLED app -- a DMG ticket does not travel
  with an app copied out of it -- and an app-level ticket is also what
  makes offline first launch work. notarize_dmg.sh is generalized into
  notarize_artifact.sh (app|dmg modes; apps submit as a ditto zip and
  staple onto the bundle).
- packaging/Info.plist stamps ScanStudioUpdateTeamIdentifier with the real
  Team ID -- the second independent blocker on the same updater gate,
  called out by docs/AUTO-UPDATE.md and skipped by the first commit.
- The notarytool submission capture no longer loses its diagnostics when
  notarytool exits non-zero (auth failure, network error, timeout): the
  capture tolerates the exit code and the status line in the output stays
  the single authority, so the log always shows WHY.
- The .p8 encoding contract is explicit: the secret is raw PEM (env
  renamed from _B64, misleading), written once per job with a
  BEGIN PRIVATE KEY guard that fails in seconds instead of after a
  40-minute build; stapler validate runs after every staple (the direct
  ticket check spctl cannot make); key material is also removed in the
  always() cleanup; the release job gains a timeout now that it waits on
  Apple round-trips.
- New signing-dry-run.yml (workflow_dispatch): rehearses the ENTIRE path
  on one arch without tagging -- keychain import, notarization-grade
  package, an assertion that the disable-library-validation entitlement
  landed on the bridge interpreter and ONLY there, app notarize + staple,
  a live proof that the hardened entitled interpreter can ctypes-dlopen
  the bundled libusb (the check the notary service cannot make), DMG
  build/notarize/staple, artifact upload for manual inspection. The
  release path's first execution is no longer a published tag.
- The entitlement's justification comment now cites the real reason (host
  libsane via find_library is foreign-team-signed -- exactly what library
  validation blocks; the bundled same-team libusb would pass on its own);
  the Mach-O sweep gains a fork-saving pre-filter (executable-bit or
  *.so/*.dylib; file(1) stays the authority) and a provenance note that
  release signing rewrites files whose hashes were pinned pre-signing;
  package_dmg.sh labels its digest pre-staple; docs/AUTO-UPDATE.md now
  describes the shipped trust state and the real secret names.
@rohanpandula
rohanpandula merged commit e9bcd97 into main Aug 14, 2026
19 checks passed
@rohanpandula
rohanpandula deleted the feat/macos-signing-notarization branch August 14, 2026 22:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant